---
title: HM - 鸿蒙-美团外卖
---

# 鸿蒙-4. 美团外卖

## 阶段案例-美团外卖

|商品页|购物车|
|----|----|
|<img src="./images/34.png" width="320" />|<img src="./images/35.png" width="320" />|


### 1. 页面结构-入口页面


```typescript title="pages/Index.ets"
import { Cart } from '../components/Cart'
import { Footer } from '../components/Footer'
import { MenuWrapper } from '../components/MenuWrapper'
import { Nav } from '../components/Nav'

@Entry
@Component
struct Index {

  @State
  showCart: boolean = false

  build() {
    Stack({ alignContent: Alignment.Bottom }) {
      Column() {
        Nav()
        MenuWrapper()
      }
      .width('100%')
      .height('100%')
      if (this.showCart) {
        Cart()
      }
      Footer({ showCart: $showCart })
    }
  }
}
```

### 2. 页面结构-底部组件

```typescript title="components/Footer.ets"
@Component
export struct Footer {
  @Link
  showCart: boolean

  build() {
    Row() {
      Row() {
        Badge({
          value: '0',
          position: BadgePosition.Right,
          style: { badgeSize: 18 }
        }) {
          Image('https://zqran.gitee.io/images/waimai/cart-2.png')
            .width(47)
            .height(69)
            .position({ y: -19 })
        }
        .width(50)
        .height(50)
        .margin({ left: 25, right: 10 })
        .onClick(() => {
          this.showCart = !this.showCart
        })

        Column() {
          Text() {
            Span('¥')
              .fontColor('#fff')
              .fontSize(12)
            Span('0.00')
              .fontColor('#fff')
              .fontSize(24)
          }

          Text('预估另需配送费 ¥5')
            .fontSize(12)
            .fontColor('#999')
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Start)

        Text('去结算')
          .backgroundColor('#f8c74e')
          .alignSelf(ItemAlign.Stretch)
          .padding(15)
          .borderRadius({
            topRight: 25,
            bottomRight: 25
          })
      }
      .height(50)
      .layoutWeight(1)
      .backgroundColor('#222426')
      .borderRadius(25)
    }
    .padding(15)
    .height(80)
  }
}
```


### 3. 页面结构-导航组件

```typescript  title="components/Nav.ets"
@Component
export struct Nav {
  @Builder
  NavItem(active: boolean, title: string, subTitle?: string) {
    Column() {
      Text() {
        Span(title)
        if (subTitle) {
          Span(' ' + subTitle)
            .fontSize(10)
            .fontColor(active ? '#000' : '#666')
        }
      }.layoutWeight(1)
      .fontColor(active ? '#000' : '#666')
      .fontWeight(active ? FontWeight.Bold : FontWeight.Normal)

      Text()
        .height(1)
        .width(20)
        .margin({ left: 6 })
        .backgroundColor(active ? '#fa0' : 'transparent')
    }
    .width(73)
    .alignItems(HorizontalAlign.Start)
    .padding({ top: 3 })
  }

  build() {
    Row() {
      this.NavItem(true, '点菜')
      this.NavItem(false, '评价', '1796')
      this.NavItem(false, '商家')

      Row() {
        Image($r('app.media.ic_public_input_search'))
          .width(14)
          .aspectRatio(1)
          .fillColor('#999')
        Text('请输入菜品名称')
          .fontSize(12)
          .fontColor('#999')
      }
      .backgroundColor('#eee')
      .height(25)
      .borderRadius(13)
      .padding({ left: 5, right: 5 })
      .layoutWeight(1)
    }
    .padding({ left: 15, right: 15 })
    .height(40)
    .border({ width: { bottom: 0.5 }, color: '#e4e4e4' })
  }
}
```

### 4. 页面结构-商品菜单和商品列表

```typescript title="components/MenuWrapper.ets"

import { MenuWrapperItem } from './MenuWrapperItem'

@Component
export struct MenuWrapper {
  list: string[] = ['一人套餐', '特色烧烤', '杂粮主食']
  @State
  activeIndex: number = 0

  build() {
    Row() {
      Column() {
        ForEach(this.list, (item: string, index: number) => {
          Text(item)
            .height(50)
            .width('100%')
            .textAlign(TextAlign.Center)
            .fontSize(14)
            .backgroundColor(this.activeIndex === index ? '#fff' : '#f5f5f5')
            .onClick(() => {
              this.activeIndex = index
            })
        })
      }
      .width(90)

      List() {
        ListItem(){
          MenuWrapperItem()
        }
      }
      .layoutWeight(1)
      .height('100%')
      .backgroundColor('#fff')
    }
    .layoutWeight(1)
    .alignItems(VerticalAlign.Top)
    .backgroundColor('#f5f5f5')
  }
}
```

```typescript title="components/MenuWrapperItem.ets"
import { CalcBtn } from './CalcBtn'

@Component
export struct MenuWrapperItem {
  build() {
    Row() {
      Image('https://zqran.gitee.io/images/waimai/8078956697.jpg')
        .width(90)
        .aspectRatio(1)
      Column({ space: 5 }) {
        Text('小份酸汤莜面鱼鱼+肉夹馍套餐')
          .textOverflow({
            overflow: TextOverflow.Ellipsis,
          })
          .maxLines(2)
          .fontWeight(600)
        Text('酸汤莜面鱼鱼，主料：酸汤、莜面 肉夹馍，主料：白皮饼、猪肉')
          .textOverflow({
            overflow: TextOverflow.Ellipsis,
          })
          .maxLines(1)
          .fontSize(12)
          .fontColor('#333')
        Text('点评网友推荐')
          .fontSize(10)
          .backgroundColor('#fff5e2')
          .fontColor('#ff8000')
          .padding({ top: 2, bottom: 2, right: 5, left: 5 })
          .borderRadius(2)
        Text() {
          Span('月销售40')
          Span(' ')
          Span('好评度100%')
        }
        .fontSize(12)
        .fontColor('#999')

        Row() {
          Text() {
            Span('¥ ')
              .fontColor('#ff8000')
              .fontSize(10)
            Span('34.23')
              .fontColor('#ff8000')
              .fontWeight(FontWeight.Bold)
          }

          CalcBtn({ icon: $r('app.media.ic_public_add_filled') })
        }
        .justifyContent(FlexAlign.SpaceBetween)
        .width('100%')
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
      .padding({ left: 10, right: 10 })
    }
    .padding(10)
    .alignItems(VerticalAlign.Top)
  }
}
```

### 5. 业务逻辑-渲染商品菜单和列表



1）定义 model 数据模型

```typescript title="models/index.ets"
export class FoodItem {
  id: number
  name: string
  like_ratio_desc: string
  food_tag_list: string[]
  price: number
  picture: string
  description: string
  tag: string
  month_saled: number
}

export class Category {
  tag: string
  name: string
  foods: FoodItem[]
}
```

2）使用 `http` 发送请求，获取数据


```typescript {1,6,16,17,19-28,34} title="pages/Index.ets"
import http from '@ohos.net.http'
import { Cart } from '../components/Cart'
import { Footer } from '../components/Footer'
import { MenuWrapper } from '../components/MenuWrapper'
import { Nav } from '../components/Nav'
import { Category } from '../models'

const req = http.createHttp()

@Entry
@Component
struct Index {
  @State
  showCart: boolean = false

  @State
  categoryList: Category[] = []

  aboutToAppear() {
    req.request('http://172.16.39.26:3000/takeaway')
      .then(res => {
        const data = JSON.parse(res.result as string) as Category[]
        this.categoryList = data
      })
      .catch(err => {
        console.error('MEITU', err.message)
      })
  }

  build() {
    Stack({ alignContent: Alignment.Bottom }) {
      Column() {
        Nav()
        MenuWrapper({ categoryList: $categoryList })
      }
      .width('100%')
      .height('100%')

      if (this.showCart) {
        Cart()
      }
      Footer({ showCart: $showCart })
    }
  }
}
```

3）传入列表数据给，商品菜单组件，进行渲染

```typescript {1,6,7,14-15,29-33} title="components/MenuWrapper.ets"
import { Category, FoodItem } from '../models'
import { MenuWrapperItem } from './MenuWrapperItem'

@Component
export struct MenuWrapper {
  @Link
  categoryList: Category[]
  @State
  activeIndex: number = 0

  build() {
    Row() {
      Column() {
        ForEach(this.categoryList, (item: Category, index: number) => {
          Text(item.name)
            .height(50)
            .width('100%')
            .textAlign(TextAlign.Center)
            .fontSize(14)
            .backgroundColor(this.activeIndex === index ? '#fff' : '#f5f5f5')
            .onClick(() => {
              this.activeIndex = index
            })
        })
      }
      .width(90)

      List() {
        ForEach(this.categoryList[this.activeIndex]?.foods, (item: FoodItem) => {
          ListItem() {
            MenuWrapperItem({ food: item })
          }
        })
      }
      .layoutWeight(1)
      .height('100%')
      .backgroundColor('#fff')
    }
    .layoutWeight(1)
    .alignItems(VerticalAlign.Top)
    .backgroundColor('#f5f5f5')
  }
}
```


```typescript {1,7,8,12,16,22,29,30,38,40,50} title="components/MenuWrapperItem.ets"
import { FoodItem } from '../models'
import { CalcBtn } from './CalcBtn'

@Component
export struct MenuWrapperItem {

  @ObjectLink
  food: FoodItem

  build() {
    Row() {
      Image(this.food.picture)
        .width(90)
        .aspectRatio(1)
      Column({ space: 5 }) {
        Text(this.food.name)
          .textOverflow({
            overflow: TextOverflow.Ellipsis,
          })
          .maxLines(2)
          .fontWeight(600)
        Text(this.food.description)
          .textOverflow({
            overflow: TextOverflow.Ellipsis,
          })
          .maxLines(1)
          .fontSize(12)
          .fontColor('#333')
        ForEach(this.food.food_tag_list, (tag) => {
          Text(tag)
            .fontSize(10)
            .backgroundColor('#fff5e2')
            .fontColor('#ff8000')
            .padding({ top: 2, bottom: 2, right: 5, left: 5 })
            .borderRadius(2)
        })
        Text() {
          Span('月销售'+this.food.month_saled)
          Span(' ')
          Span(this.food.like_ratio_desc)
        }
        .fontSize(12)
        .fontColor('#999')

        Row() {
          Text() {
            Span('¥ ')
              .fontColor('#ff8000')
              .fontSize(10)
            Span(this.food.price.toFixed(2))
              .fontColor('#ff8000')
              .fontWeight(FontWeight.Bold)
          }

          CalcBtn({ icon: $r('app.media.ic_public_add_filled') })
        }
        .justifyContent(FlexAlign.SpaceBetween)
        .width('100%')
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
      .padding({ left: 10, right: 10 })
    }
    .padding(10)
    .alignItems(VerticalAlign.Top)
  }
}
```



### 6. 页面结构-购物车

- 抽取计算案例组件
- 搭建购物车页面

```typescript title="components/CalcBtn.ets"
@Component
export struct CalcBtn {

  icon: Resource

  plain?: boolean

  build() {
    Row() {
      Image(this.icon)
        .width(10)
        .aspectRatio(1)
    }
    .width(16)
    .aspectRatio(1)
    .backgroundColor(this.plain ? '#fff' : '#f8c74e')
    .border(this.plain ? { width: 0.5 , color: '#f8c74e'}: {})
    .borderRadius(4)
    .justifyContent(FlexAlign.Center)
  }
}
```

```typescript title="components/Cart.ets"
import { CartItem } from './CartItem'
@Component
export struct Cart {
  build() {
    Column(){
      Column(){
        Row(){
          Text('购物车')
            .fontSize(14)
          Text('清空购物车')
            .fontSize(14)
            .fontColor('#999')
        }
        .width('100%')
        .height(40)
        .justifyContent(FlexAlign.SpaceBetween)
        .padding({ left: 15, right: 15 })
        .border({ width: { bottom: 0.5 }, color: '#e4e4e4' })
        // 购物车列表
        List(){
          ListItem(){
            CartItem()
          }
          ListItem(){
            CartItem()
          }
          ListItem(){
            CartItem()
          }
        }
        .divider({
          strokeWidth: 0.5,
          color: '#e4e4e4'
        })
        .padding({ left: 15, right: 15 })
      }
      .width('100%')
      .padding({ bottom: 100 })
      .backgroundColor('#fff')
      .borderRadius({
        topLeft: 16,
        topRight: 16
      })
    }
    .height('100%')
    .width('100%')
    .backgroundColor('rgba(0,0,0,0.5)')
    .justifyContent(FlexAlign.End)
  }
}
```

```typescript title="components/CartItem.ets"
import { CalcBtn } from './CalcBtn'
@Component
export struct CartItem {
  build() {
    Row(){
      Image('https://zqran.gitee.io/images/waimai/7384994864.jpg')
        .width(60)
        .aspectRatio(1)
      Column({ space: 10 }){
        Text('小份酸汤莜面鱼鱼+肉夹馍套餐')
          .textOverflow({
            overflow: TextOverflow.Ellipsis
          })
          .maxLines(2)
        Row(){
          Text(){
            Span('¥ ')
              .fontSize(10)
              .fontColor('#ff8000')
            Span('23.23')
              .fontWeight(600)
              .fontColor('#ff8000')
          }

          Row({ space: 10 }){
            CalcBtn({ icon: $r('app.media.ic_screenshot_line'), plain: true })
            Text('0')
              .fontSize(14)
            CalcBtn({ icon: $r('app.media.ic_public_add_filled') })
          }
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
      .padding({ left: 10 })
    }
    .padding({ top: 15, bottom: 15 })
    .alignItems(VerticalAlign.Top)
  }
}
```

### 7. 业务逻辑-加入购物车


1）购物车数据模型

```typescript title="models/index.ets"
export class CartItemModel {
  id: number
  name: string
  price: number
  picture: string
  count: number
}
```

2）购物车添加逻辑

```typescript title="utils/index.ets"
import { CartItemModel, FoodItem } from '../models'

export const CART_KEY = 'CART_KEY'

export const getCart = () => {
  return JSON.parse(AppStorage.Get(CART_KEY) || '[]') as CartItemModel[]
}

export const addCart = (food: FoodItem) => {
  const cart = getCart()
  const f = cart.find(f => f.id === food.id)
  if (f) {
    f.count++
  } else {
    const { id, price, picture, name } = food
    cart.unshift({
      id,
      price,
      picture,
      name,
      count: 1
    })
  }
  AppStorage.Set(CART_KEY, JSON.stringify(cart))
}
```

3）购物车状态持久化

```typescript title="pages/Index.ets"
import { CART_KEY } from '../utils'


PersistentStorage.PersistProp(CART_KEY, '[]')
```


4）监听购物车数据变化，设置购物车状态，底部组件显示数量和总价

```typescript title="pages/Index.ets"
  @StorageProp(CART_KEY)
  @Watch('onUpdateCart')
  cartJson: string = '[]'
  @State
  cart: CartItem[] = JSON.parse(this.cartJson)
  onUpdateCart () {
    this.cart = JSON.parse(this.cartJson)
  }
```

```typescript title="pages/Index.ets"
Footer({ showCart: $showCart, cart: $cart })
```

```typescript title="components/Footer.ets"
@Link
cart: CartItem[]

  // ...
          Badge({
          value: this.cart.reduce((p, c) => p + c.count, 0) + '',
          })

  // ...
          Span(this.cart.reduce((p, c) => p + (c.count * c.price * 100) / 100, 0).toFixed(2))
          .fontColor('#fff')
          .fontSize(24)

```


5）添加购物车

```typescript title="components/MenuWrapperItem.ets"
          CalcBtn({ icon: $r('app.media.ic_public_add_filled') })
            .onClick(() => {
              addCart(this.food)
              promptAction.showToast({ message: '添加购物车成功' })
            })
```



### 8. 业务逻辑-购物车管理

1）渲染购物车

```typescript title="pages/Index.ets"
      if (this.showCart) {
        Cart({ cart: $cart })
      }
```

```typescript title="components/Cart.ets"
@Component
export struct Cart {
  @Link
  cart: CartItemModel[]

  // ...

          List({ space: 30 }) {
          ForEach(this.cart, (item:CartItemModel) => {
            ListItem() {
              CartItemComp({ item })
            }
          })
        }
```

```typescript title="components/CartItem.ets"
import { CartItemModel } from '../models'
import { CalcBtn } from './CalcBtn'

@Component
export struct CartItem {

  @ObjectLink
  item: CartItemModel

  build() {
    Row() {
      Image(this.item.picture)
        .width(60)
        .aspectRatio(1)
        .borderRadius(8)
      Column({ space: 5 }) {
        Text(this.item.name)
          .fontSize(14)
          .textOverflow({
            overflow: TextOverflow.Ellipsis
          })
          .maxLines(2)
        Row() {
          Text() {
            Span('¥ ')
              .fontColor('#ff8000')
              .fontSize(10)
            Span(this.item.price.toFixed(2))
              .fontColor('#ff8000')
              .fontWeight(FontWeight.Bold)
          }

          Row() {
            CalcBtn({ icon: $r('app.media.ic_screenshot_line'), plain: true })
            Text(this.item.count+'')
              .padding(10)
              .fontSize(12)
            CalcBtn({ icon: $r('app.media.ic_public_add_filled') })
          }
        }
        .justifyContent(FlexAlign.SpaceBetween)
        .width('100%')
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
      .padding({ left: 10, right: 10 })
    }
    .alignItems(VerticalAlign.Top)
  }
}
```


2）购物车数量修改

```typescript title="utils/index.ets"
export const addCart = (food: FoodItem | CartItemModel) => { ... }

export const delCart = (id: number) => {
  const cart = getCart()
  const f = cart.find(f => f.id === id)
  if (f && f.count > 0) {
    f.count--
  }
  AppStorage.Set(CART_KEY, JSON.stringify(cart))
}
```

```typescript title="components/CartItem.ets"
            CalcBtn({ icon: $r('app.media.ic_screenshot_line'), plain: true })
              .onClick(() => {
                delCart(this.item.id)
              })
            Text(this.item.count+'')
              .padding(10)
              .fontSize(12)
            CalcBtn({ icon: $r('app.media.ic_public_add_filled') })
              .onClick(()=>{
                addCart(this.item)
              })
```


3）清空购物车


```typescript title="utils/index.ets"
export const clearCart  = () => {
  AppStorage.Set(CART_KEY, '[]')
}
```

```typescript title="components/Cart.ets"
          Text('清空购物车')
            .fontSize(12)
            .fontColor('#999')
            .onClick(() => {
              clearCart()
            })
```

